// stolen from http-music

import {
  commandExists,
  killProcess,
  getTimeStrings,
  getTimeStringsFromSec,
} from './general-util.js'

import {spawn} from 'node:child_process'
import {statSync} from 'node:fs'
import {unlink} from 'node:fs/promises'
import EventEmitter from 'node:events'
import path from 'node:path'
import url from 'node:url'

import Socat from './socat.js'

export class Player extends EventEmitter {
  constructor(processOptions = []) {
    super()

    this.processOptions = processOptions

    this.disablePlaybackStatus = false
    this.isLooping = false
    this.isPaused = false
    this.volume = 100
    this.volumeMultiplier = 1.0
  }

  set process(newProcess) {
    this._process = newProcess
    this._process.on('exit', code => {
      if (code !== 0 && !this._killed) {
        this.emit('crashed', code)
      }

      this._killed = false
    })
  }

  get process() {
    return this._process
  }

  playFile(_file, _startTime) {}
  seekAhead(_secs) {}
  seekBack(_secs) {}
  seekTo(_timeInSecs) {}
  seekToStart() {}
  volUp(_amount) {}
  volDown(_amount) {}
  setVolume(_value) {}
  updateVolume() {}
  togglePause() {}
  toggleLoop() {}
  setPause() {}
  setLoop() {}

  async kill() {
    if (this.process) {
      this._killed = true
      await killProcess(this.process)
    }
  }

  printStatusLine(data) {
    // Quick sanity check - we don't want to print the status line if it's
    // disabled! Hopefully printStatusLine won't be called in that case, but
    // if it is, we should be careful.
    if (!this.disablePlaybackStatus) {
      this.emit('printStatusLine', data)
    }
  }

  setVolumeMultiplier(value) {
    this.volumeMultiplier = value
    this.updateVolume()
  }

  fadeIn() {
    const interval = 50
    const duration = 1000
    const delta = 1.0 - this.volumeMultiplier
    const id = setInterval(() => {
      this.volumeMultiplier += delta * interval / duration
      if (this.volumeMultiplier >= 1.0) {
        this.volumeMultiplier = 1.0
        clearInterval(id)
      }
      this.updateVolume()
    }, interval)
  }
}

export class MPVPlayer extends Player {
  // The more powerful MPV player. MPV is virtually impossible for a human
  // being to install; if you're having trouble with it, try the SoX player.

  getMPVOptions(file, startTime) {
    const opts = [
      `--term-status-msg='${this.getMPVStatusMessage()}'`,
      '--no-video',
      file
    ]

    if (this.isLooping) {
      opts.unshift('--loop')
    }

    if (this.isPaused) {
      opts.unshift('--pause')
    }

    if (startTime) {
      opts.unshift('--start=' + startTime)
    }

    opts.unshift('--volume=' + this.volume * this.volumeMultiplier)

    return opts
  }

  getMPVStatusMessage() {
    // Note: This function shouldn't include any single-quotes! It probably
    // (NOTE: PROBABLY) wouldn't cause any security issues, but it will break
    // --term-status-msg parsing and might keep mpv from starting at all.

    return '${=time-pos} ${=duration} ${=percent-pos}'
  }

  playFile(file, startTime) {
    this.process = spawn('mpv', this.getMPVOptions(file, startTime).concat(this.processOptions))

    let lastPercent = 0

    this.process.stderr.on('data', data => {
      if (this.disablePlaybackStatus) {
        return
      }

      const match = data.toString().match(
        /([0-9.]+) ([0-9.]+) ([0-9.]+)/
      )

      if (match) {
        const [
          curSecTotal,
          lenSecTotal,
          percent
        ] = match.slice(1)

        if (parseInt(percent) < lastPercent) {
          // mpv forgets commands you sent it whenever it loops, so you
          // have to specify them every time it loops. We do that whenever the
          // position in the track decreases, since that means it may have
          // looped.
          this.setLoop(this.isLooping)
        }

        lastPercent = parseInt(percent)

        this.printStatusLine(getTimeStringsFromSec(curSecTotal, lenSecTotal))
      }

      this.updateVolume();
    })

    return new Promise(resolve => {
      this.process.once('close', resolve)
    })
  }
}

export class ControllableMPVPlayer extends MPVPlayer {
  getMPVOptions(...args) {
    return ['--input-ipc-server=' + this.socat.path, ...super.getMPVOptions(...args)]
  }

  playFile(file, startTime) {
    this.removeSocket(this.socketPath)

    do {
      this.socketPath = path.join(path.dirname(url.fileURLToPath(import.meta.url)), 'mtui-socket-' + Math.floor(Math.random() * 10000))
    } while (this.existsSync(this.socketPath))

    this.socat = new Socat(this.socketPath)

    const mpv = super.playFile(file, startTime)

    mpv.then(() => this.removeSocket(this.socketPath))

    return mpv
  }

  existsSync(path) {
    try {
      statSync(path)
      return true
    } catch (error) {
      return false
    }
  }

  sendCommand(...command) {
    if (this.socat) {
      this.socat.send(JSON.stringify({command}))
    }
  }

  seekAhead(secs) {
    this.sendCommand('seek', secs)
  }

  seekBack(secs) {
    this.sendCommand('seek', -secs)
  }

  seekTo(timeInSecs) {
    this.sendCommand('seek', timeInSecs, 'absolute')
  }

  seekToStart() {
    this.seekTo(0)
  }

  volUp(amount) {
    this.setVolume(this.volume + amount)
  }

  volDown(amount) {
    this.setVolume(this.volume - amount)
  }

  setVolume(value) {
    this.volume = value
    this.volume = Math.max(0, this.volume)
    this.volume = Math.min(100, this.volume)
    this.updateVolume()
  }

  updateVolume() {
    this.sendCommand('set_property', 'volume', this.volume * this.volumeMultiplier)
  }

  togglePause() {
    this.isPaused = !this.isPaused
    this.sendCommand('cycle', 'pause')
  }

  toggleLoop() {
    this.isLooping = !this.isLooping
    this.sendCommand('cycle', 'loop')
  }

  setPause(val) {
    if (!!val !== this.isPaused) {
      this.togglePause()
    }
  }

  setLoop(val) {
    if (!!val !== this.isLooping) {
      this.toggleLoop()
    }
  }

  async kill() {
    const path = this.socketPath
    delete this.socketPath
    if (this.socat) {
      await this.socat.dispose()
      await this.socat.stop()
    }
    await super.kill()
    await this.removeSocket(path)
  }

  async removeSocket(path) {
    if (path) {
      await unlink(path).catch(() => {})
    }
  }
}

export class SoXPlayer extends Player {
  playFile(file, startTime) {
    // SoX's play command is useful for systems that don't have MPV. SoX is
    // much easier to install (and probably more commonly installed, as well).
    // You don't get keyboard controls such as seeking or volume adjusting
    // with SoX, though.

    this._file = file

    this.process = spawn('play', [file].concat(
      this.processOptions,
      startTime ? ['trim', startTime] : []
    ))

    this.process.stdout.on('data', data => {
      process.stdout.write(data.toString())
    })

    // Most output from SoX is given to stderr, for some reason!
    this.process.stderr.on('data', data => {
      // The status line starts with "In:".
      if (data.toString().trim().startsWith('In:')) {
        if (this.disablePlaybackStatus) {
          return
        }

        const timeRegex = String.raw`([0-9]*):([0-9]*):([0-9]*)\.([0-9]*)`
        const match = data.toString().trim().match(new RegExp(
          `^In:([0-9.]+%)\\s*${timeRegex}\\s*\\[${timeRegex}\\]`
        ))

        if (match) {
          // SoX takes a loooooot of math in order to actually figure out the
          // duration, since it outputs the current time and the remaining time
          // (but not the duration).

          const [
            curHour, curMin, curSec, curSecFrac, // ##:##:##.##
            remHour, remMin, remSec, remSecFrac // ##:##:##.##
          ] = match.slice(2).map(n => parseInt(n))

          const duration = Math.round(
            (curHour + remHour) * 3600 +
            (curMin + remMin) * 60 +
            (curSec + remSec) * 1 +
            (curSecFrac + remSecFrac) / 100
          )

          const lenHour = Math.floor(duration / 3600)
          const lenMin = Math.floor((duration - lenHour * 3600) / 60)
          const lenSec = Math.floor(duration - lenHour * 3600 - lenMin * 60)

          this.printStatusLine(getTimeStrings({curHour, curMin, curSec, lenHour, lenMin, lenSec}))
        }
      }
    })

    return new Promise(resolve => {
      this.process.on('close', () => resolve())
    }).then(() => {
      if (this._restartPromise) {
        const p = this._restartPromise
        this._restartPromise = null
        return p
      }
    })
  }

  async seekToStart() {
    // SoX doesn't support a command interface to interact while playback is
    // ongoing. However, we can simulate seeking to the start by restarting
    // playback altogether. We just need to be careful not to resolve the
    // original playback promise before the new one is complete!

    if (!this._file) {
      return
    }

    let resolve = null
    let reject = null

    // The original call of playFile() will yield control to this promise, which
    // we bind to the resolve/reject of a new call to playFile().
    this._restartPromise = new Promise((res, rej) => {
      resolve = res
      reject = rej
    })

    await this.kill()

    this.playFile(this._file).then(resolve, reject)
  }
}

export class GhostPlayer extends Player {
  // The music player which makes believe! This player doesn't actually process
  // any files nor interface with an underlying binary or API to provide real
  // sound playback. It just provides all the usual interfaces as best as it
  // can - simulating playback time by accounting for pause/resume, seeking,
  // and so on, for example.

  statusInterval = 250

  // This is always a number if a track is "loaded", whether or not paused.
  // It's null if no track is loaded (aka "stopped"). It's used as the base
  // for the playback time, if resumed, or directly as the current playback
  // time, if paused. (Note: time is internally tracked in milliseconds.)
  #playingFrom = null

  // This is null if no track is "loaded" (aka "stopped") or if paused.
  // It's used to calculate the current playback time when resumed.
  #resumedSince = null

  // These are interval/timer identifiers and are null if no track is loaded
  // or if paused.
  #statusInterval = null
  #doneTimeout = null
  #loopTimeout = null

  // This is a callback which resolves the playFile promise. It exists at the
  // same time as playingFrom, i.e. while a track is "loaded", whether or not
  // paused.
  #resolvePlayFilePromise = null

  // This is reset to null every time a track is started. It can be provided
  // externally with setDuration(). It's used to control emitting a "done"
  // event.
  #duration = null

  setDuration(duration) {
    // This is a unique interface on GhostPlayer, not found on other players.
    // Most players inherently know when to resolve playFile thanks to the
    // child process exiting (or outputting a message) when the input file is
    // done. GhostPlayer is intended not to operate on actual files at all, so
    // we couldn't even read duration metadata if we wanted to. So, this extra
    // interface can be used to provide that data instead!

    if (this.#playingFrom === null) {
      return
    }

    if (duration !== null) {
      if (this.#getPlaybackTime() >= duration * 1000) {
        // No need to do anything else if we're already done playing according
        // to the provided duration.
        this.#donePlaying()
        return
      }
    }

    this.#affectTimeRemaining(() => {
      this.#duration = duration
    })
  }

  playFile(file, startTime = 0) {
    // This function is public, and might be called without any advance notice,
    // so clear existing playback info. This also resolves a prior playFile
    // promise.
    if (this.#playingFrom !== null) {
      this.#donePlaying()
    }

    const promise = new Promise(resolve => {
      this.#resolvePlayFilePromise = resolve
    })

    this.#playingFrom = 1000 * startTime

    // It's possible to have paused the player before the next track came up,
    // in which case playback begins paused.
    if (!this.isPaused) {
      this.#resumedSince = Date.now()
    }

    this.#status()
    this.#startStatusInterval()

    // We can't start any end-of-track timeouts here because we don't have a
    // duration yet - we'll instate the appropriate timeout once it's been
    // provided externally (with setDuration()).

    return promise
  }

  setPause(paused) {
    if (!paused && this.isPaused) {
      this.#resumedSince = Date.now()

      this.#status()
      this.#startStatusInterval()

      if (this.#duration !== null) {
        if (this.isLooping) {
          this.#startLoopTimeout()
        } else {
          this.#startDoneTimeout()
        }
      }
    }

    if (paused && !this.isPaused) {
      this.#playingFrom = this.#getPlaybackTime()
      this.#resumedSince = null

      this.#status()
      this.#clearStatusInterval()

      if (this.#duration !== null) {
        if (this.isLooping) {
          this.#clearLoopTimeout()
        } else {
          this.#clearDoneTimeout()
        }
      }
    }

    this.isPaused = paused
  }

  togglePause() {
    this.setPause(!this.isPaused)
  }

  setLoop(looping) {
    if (!looping && this.isLooping) {
      if (this.#duration !== null) {
        this.#clearLoopTimeout()
        this.#startDoneTimeout()
      }
    }

    if (looping && !this.isLooping) {
      if (this.#duration !== null) {
        this.#clearDoneTimeout()
        this.#startLoopTimeout()
      }
    }

    this.isLooping = looping
  }

  toggleLoop() {
    this.setLoop(!this.isLooping)
  }

  seekToStart() {
    if (this.#playingFrom === null) {
      return
    }

    this.seekTo(0)
  }

  seekAhead(secs) {
    if (this.#playingFrom === null) {
      return
    }

    this.seekTo(this.#getPlaybackTime() / 1000 + secs)
  }

  seekBack(secs) {
    if (this.#playingFrom === null) {
      return
    }

    this.seekTo(this.#getPlaybackTime() / 1000 - secs)
  }

  seekTo(timeInSecs) {
    if (this.#playingFrom === null) {
      return
    }

    let seekTime = null

    if (this.#duration !== null && timeInSecs > this.#duration) {
      // Seeking past the duration of the track either loops it or ends it.
      if (this.isLooping) {
        seekTime = 0
      } else {
        this.#donePlaying()
        return
      }
    } else if (timeInSecs < 0) {
      // You can't seek before the beginning of a track!
      seekTime = 0
    } else {
      // Otherwise, just seek to the specified time.
      seekTime = timeInSecs
    }

    this.#affectTimeRemaining(() => {
      if (this.#resumedSince !== null) {
        // Seeking doesn't pause, but it does functionally reset where we're
        // measuring playback time from.
        this.#resumedSince = Date.now()
      }

      this.#playingFrom = seekTime * 1000
    })
  }

  async kill() {
    if (this.#playingFrom === null) {
      return
    }

    this.#donePlaying()
  }

  #affectTimeRemaining(callback) {
    // Changing the time remaining (i.e. the delta between current playback
    // time and duration) means any timeouts which run when the track ends
    // need to be reset with the new delta. This function also handily creates
    // those timeouts in the first place if a duration hadn't been set before.

    if (this.#resumedSince !== null && this.#duration !== null) {
      // If there was an existing timeout for the end of the track, clear it.
      // We're going to instate a new one in a moment.
      if (this.isLooping) {
        this.#clearLoopTimeout()
      } else {
        this.#clearDoneTimeout()
      }
    }

    // Do something which will affect the time remaining.
    callback()

    this.#status()

    if (this.#resumedSince !== null && this.#duration !== null) {
      // Start a timeout for the (possibly new) end of the track, but only if
      // we're actually playing!
      if (this.isLooping) {
        this.#startLoopTimeout()
      } else {
        this.#startDoneTimeout()
      }
    }
  }

  #startStatusInterval() {
    if (this.#statusInterval !== null) {
      throw new Error(`Status interval already set (this code shouldn't be reachable!)`)
    }

    this.#statusInterval = setInterval(() => this.#status(), this.statusInterval)
  }

  #startDoneTimeout() {
    if (this.#doneTimeout !== null) {
      throw new Error(`Done timeout already set (this code shouldn't be reachable!)`)
    }

    const timeoutInMilliseconds = this.#duration * 1000 - this.#getPlaybackTime()
    this.#doneTimeout = setTimeout(() => this.#donePlaying(), timeoutInMilliseconds)
  }

  #startLoopTimeout() {
    if (this.#loopTimeout !== null) {
      throw new Error(`Loop timeout already set (this code shouldn't be reachable!)`)
    }

    const timeoutInMilliseconds = this.#duration * 1000 - this.#getPlaybackTime()
    this.#loopTimeout = setTimeout(() => this.#loopAtEnd(), timeoutInMilliseconds)
  }

  #clearStatusInterval() {
    if (this.#statusInterval === null) {
      throw new Error(`Status interval not set yet (this code shouldn't be reachable!)`)
    }

    clearInterval(this.#statusInterval)
    this.#statusInterval = null
  }

  #clearDoneTimeout() {
    if (this.#doneTimeout === null) {
      throw new Error(`Done timeout not set yet (this code shouldn't be reachable!)`)
    }

    clearTimeout(this.#doneTimeout)
    this.#doneTimeout = null
  }

  #clearLoopTimeout() {
    if (this.#loopTimeout === null) {
      throw new Error(`Loop timeout nout set yet (this code shouldn't be reachable!)`)
    }

    clearTimeout(this.#loopTimeout)
    this.#loopTimeout = null
  }

  #status() {
    // getTimeStringsFromSec supports null duration, so we don't need to
    // perform a specific check here.
    const timeInSecs = this.#getPlaybackTime() / 1000
    this.printStatusLine(getTimeStringsFromSec(timeInSecs, this.#duration))
  }

  #donePlaying() {
    if (this.#resumedSince !== null) {
      this.#clearStatusInterval()
    }

    // Run this first, while we still have a track "loaded". This ensures the
    // end-of-track timeouts get cleared appropriately (if they've been set).
    this.setDuration(null)

    this.#playingFrom = null
    this.#resumedSince = null

    // No, this doesn't have any spooky tick order errors - resolved promises
    // always continue on a later tick of the event loop, not the current one.
    // So the second line here will always happen before any potential future
    // calls to playFile().
    this.#resolvePlayFilePromise()
    this.#resolvePlayFilePromise = null
  }

  #loopAtEnd() {
    // Looping is just seeking back to the start! This will also cause the
    // loop timer to be reinstated (via #affectTimeRemaining).
    this.seekToStart()
  }

  #getPlaybackTime() {
    if (this.#resumedSince === null) {
      return this.#playingFrom
    } else {
      return this.#playingFrom + Date.now() - this.#resumedSince
    }
  }
}

export async function getPlayer(name = null, options = []) {
  if (name === 'ghost') {
    return new GhostPlayer(options)
  }

  if (await commandExists('mpv') && (name === null || name === 'mpv')) {
    return new ControllableMPVPlayer(options)
  } else if (name === 'mpv') {
    return null
  }

  if (await commandExists('play') && (name === null || name === 'sox')) {
    return new SoXPlayer(options)
  } else if (name === 'sox') {
    return null
  }

  return null
}
